/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0; you may not use this file except in compliance with the Elastic License
 * 2.0.
 */
package org.elasticsearch.xpack.security.cli;

import joptsimple.OptionException;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpec;
import joptsimple.OptionSpecBuilder;

import org.bouncycastle.asn1.DERIA5String;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMEncryptor;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.MultiCommand;
import org.elasticsearch.cli.ProcessInfo;
import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.Terminal.Verbosity;
import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.cli.EnvironmentAwareCommand;
import org.elasticsearch.common.network.InetAddresses;
import org.elasticsearch.common.ssl.PemUtils;
import org.elasticsearch.common.util.set.Sets;
import org.elasticsearch.core.CheckedConsumer;
import org.elasticsearch.core.CheckedFunction;
import org.elasticsearch.core.PathUtils;
import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.env.Environment;
import org.elasticsearch.xcontent.ConstructingObjectParser;
import org.elasticsearch.xcontent.ObjectParser;
import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.XContentParser;
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xpack.core.ssl.CertParsingUtils;

import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.nio.CharBuffer;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import javax.security.auth.x500.X500Principal;

/**
 * CLI tool to make generation of certificates or certificate requests easier for users
 */
class CertificateTool extends MultiCommand {

    private static final String AUTO_GEN_CA_DN = "CN=Elastic Certificate Tool Autogenerated CA";
    private static final String DESCRIPTION = "Simplifies certificate creation for use with the Elastic Stack";
    private static final String DEFAULT_CSR_ZIP = "csr-bundle.zip";
    private static final String DEFAULT_CERT_ZIP = "certificate-bundle.zip";
    private static final String DEFAULT_CA_ZIP = "elastic-stack-ca.zip";
    private static final String DEFAULT_CA_P12 = "elastic-stack-ca.p12";
    private static final BouncyCastleProvider BC_PROV = new BouncyCastleProvider();

    static final String DEFAULT_CERT_NAME = "instance";

    /**
     * Used to test whether passwords are ASCII (which PKCS/PBE requires)
     */
    private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();

    private static final int DEFAULT_DAYS = 3 * 365;
    private static final int FILE_EXTENSION_LENGTH = 4;
    static final int MAX_FILENAME_LENGTH = 255 - FILE_EXTENSION_LENGTH;
    private static final Pattern ALLOWED_FILENAME_CHAR_PATTERN = Pattern.compile(
        "[a-zA-Z0-9!@#$%^&{}\\[\\]()_+\\-=,.~'` ]{1," + MAX_FILENAME_LENGTH + "}"
    );
    private static final int DEFAULT_KEY_SIZE = 2048;

    // Older versions of OpenSSL had a max internal password length.
    // We issue warnings when writing files with passwords that would not be usable in those versions of OpenSSL.
    static final String OLD_OPENSSL_VERSION = "1.1.0";
    static final int MAX_PASSWORD_OLD_OPENSSL = 50;

    /**
     * Wraps the certgen object parser.
     */
    private static class CertificateToolParser {
        private static final ObjectParser<List<CertificateInformation>, Void> PARSER = new ObjectParser<>("certgen");

        // if the class initializer here runs before the main method, logging will not have been configured; this will lead to status logger
        // error messages from the class initializer for ParseField since it creates Logger instances; therefore, we bury the initialization
        // of the parser in this class so that we can defer initialization until after logging has been initialized
        static {
            @SuppressWarnings("unchecked")
            final ConstructingObjectParser<CertificateInformation, Void> instanceParser = new ConstructingObjectParser<>(
                "instances",
                a -> new CertificateInformation(
                    (String) a[0],
                    (String) (a[1] == null ? a[0] : a[1]),
                    (List<String>) a[2],
                    (List<String>) a[3],
                    (List<String>) a[4]
                )
            );
            instanceParser.declareString(ConstructingObjectParser.constructorArg(), new ParseField("name"));
            instanceParser.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("filename"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("ip"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("dns"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("cn"));

            PARSER.declareObjectArray(List::addAll, instanceParser, new ParseField("instances"));
        }
    }

    CertificateTool() {
        super(DESCRIPTION);
        subcommands.put("csr", new SigningRequestCommand());
        subcommands.put("cert", new GenerateCertificateCommand());
        subcommands.put("ca", new CertificateAuthorityCommand());
        subcommands.put("http", new HttpCertificateCommand());
    }

    @Override
    protected void execute(Terminal terminal, OptionSet options, ProcessInfo processInfo) throws Exception {
        try {
            super.execute(terminal, options, processInfo);
        } catch (OptionException e) {
            if (e.options().size() == 1 && e.options().contains("keep-ca-key")) {
                throw new UserException(ExitCodes.USAGE, """
                    Generating certificates without providing a CA is no longer supported.
                    Please first generate a CA with the 'ca' sub-command and provide the ca file\s
                    with either --ca or --ca-cert/--ca-key to generate certificates.""");
            } else {
                throw e;
            }
        }
    }

    static final String INTRO_TEXT = "This tool assists you in the generation of X.509 certificates and certificate\n"
        + "signing requests for use with SSL/TLS in the Elastic stack.";

    static final String INSTANCE_EXPLANATION = """
        * An instance is any piece of the Elastic Stack that requires an SSL certificate.
          Depending on your configuration, Elasticsearch, Logstash, Kibana, and Beats
          may all require a certificate and private key.
        * The minimum required value for each instance is a name. This can simply be the
          hostname, which will be used as the Common Name of the certificate. A full
          distinguished name may also be used.
        * A filename value may be required for each instance. This is necessary when the
          name would result in an invalid file or directory name. The name provided here
          is used as the directory name (within the zip) and the prefix for the key and
          certificate files. The filename is required if you are prompted and the name
          is not displayed in the prompt.
        * IP addresses and DNS names are optional. Multiple values can be specified as a
          comma separated string. If no IP addresses or DNS names are provided, you may
          disable hostname verification in your SSL configuration.""".indent(4);

    static final String CA_EXPLANATION = """
        * All certificates generated by this tool will be signed by a certificate authority (CA)
          unless the --self-signed command line option is specified.
          The tool can automatically generate a new CA for you, or you can provide your own with
          the --ca or --ca-cert command line options.""".indent(4);

    abstract static class CertificateCommand extends EnvironmentAwareCommand {
        // Common option for multiple commands.
        // Not every command uses every option, but where they are common we want to keep them consistent
        final OptionSpec<String> outputPathSpec;
        final OptionSpec<String> outputPasswordSpec;
        final OptionSpec<Integer> keysizeSpec;

        OptionSpec<Void> pemFormatSpec;
        OptionSpec<Integer> daysSpec;

        OptionSpec<String> caPkcs12PathSpec;
        OptionSpec<String> caCertPathSpec;
        OptionSpec<String> caKeyPathSpec;
        OptionSpec<String> caPasswordSpec;
        OptionSpec<String> caDnSpec;

        OptionSpec<Void> multipleNodesSpec;
        OptionSpec<String> nameSpec;
        OptionSpec<String> dnsNamesSpec;
        OptionSpec<String> ipAddressesSpec;

        OptionSpec<String> inputFileSpec;

        CertificateCommand(String description) {
            super(description);
            outputPathSpec = parser.accepts("out", "path to the output file that should be produced").withRequiredArg();
            outputPasswordSpec = parser.accepts("pass", "password for generated private keys").withOptionalArg();
            keysizeSpec = parser.accepts("keysize", "size in bits of RSA keys").withRequiredArg().ofType(Integer.class);
        }

        final void acceptCertificateGenerationOptions() {
            pemFormatSpec = parser.accepts("pem", "output certificates and keys in PEM format instead of PKCS#12");
            daysSpec = parser.accepts("days", "number of days that the generated certificates are valid")
                .withRequiredArg()
                .ofType(Integer.class);
        }

        final void acceptsCertificateAuthority() {
            caPkcs12PathSpec = parser.accepts("ca", "path to an existing ca key pair (in PKCS#12 format)").withRequiredArg();
            caCertPathSpec = parser.accepts("ca-cert", "path to an existing ca certificate")
                .availableUnless(caPkcs12PathSpec)
                .withRequiredArg();
            caKeyPathSpec = parser.accepts("ca-key", "path to an existing ca private key")
                .availableIf(caCertPathSpec)
                .requiredIf(caCertPathSpec)
                .withRequiredArg();

            caPasswordSpec = parser.accepts("ca-pass", "password for an existing ca private key or the generated ca private key")
                .withOptionalArg();

            acceptsCertificateAuthorityName();
        }

        void acceptsCertificateAuthorityName() {
            OptionSpecBuilder builder = parser.accepts(
                "ca-dn",
                "distinguished name to use for the generated ca. defaults to " + AUTO_GEN_CA_DN
            );
            if (caPkcs12PathSpec != null) {
                builder = builder.availableUnless(caPkcs12PathSpec);
            }
            if (caCertPathSpec != null) {
                builder = builder.availableUnless(caCertPathSpec);
            }
            caDnSpec = builder.withRequiredArg();
        }

        final void acceptInstanceDetails() {
            multipleNodesSpec = parser.accepts("multiple", "generate files for multiple instances");
            nameSpec = parser.accepts("name", "name of the generated certificate").availableUnless(multipleNodesSpec).withRequiredArg();
            dnsNamesSpec = parser.accepts("dns", "comma separated DNS names").availableUnless(multipleNodesSpec).withRequiredArg();
            ipAddressesSpec = parser.accepts("ip", "comma separated IP addresses").availableUnless(multipleNodesSpec).withRequiredArg();
        }

        final void acceptInputFile() {
            inputFileSpec = parser.accepts("in", "file containing details of the instances in yaml format").withRequiredArg();
        }

        // For testing
        OptionParser getParser() {
            return parser;
        }

        /**
         * Checks for output file in the user specified options or prompts the user for the output file.
         * The resulting path is stored in the {@code config} parameter.
         */
        Path resolveOutputPath(Terminal terminal, OptionSet options, String defaultFilename) throws IOException {
            return resolveOutputPath(terminal, outputPathSpec.value(options), defaultFilename);
        }

        static Path resolveOutputPath(Terminal terminal, String userOption, String defaultFilename) {
            Path file;
            if (userOption != null) {
                file = CertificateTool.resolvePath(userOption);
            } else {
                file = CertificateTool.resolvePath(defaultFilename);
                String input = terminal.readText("Please enter the desired output file [" + file + "]: ");
                if (input.isEmpty() == false) {
                    file = CertificateTool.resolvePath(input);
                }
            }
            return file.toAbsolutePath();
        }

        final int getKeySize(OptionSet options) {
            if (options.has(keysizeSpec)) {
                return keysizeSpec.value(options);
            } else {
                return DEFAULT_KEY_SIZE;
            }
        }

        final int getDays(OptionSet options) {
            if (options.has(daysSpec)) {
                return daysSpec.value(options);
            } else {
                return DEFAULT_DAYS;
            }
        }

        boolean usePemFormat(OptionSet options) {
            return options.has(pemFormatSpec);
        }

        boolean useOutputPassword(OptionSet options) {
            return options.has(outputPasswordSpec);
        }

        char[] getOutputPassword(OptionSet options) {
            return getChars(outputPasswordSpec.value(options));
        }

        protected Path resolvePath(OptionSet options, OptionSpec<String> spec) {
            final String value = spec.value(options);
            if (Strings.isNullOrEmpty(value)) {
                return null;
            }
            return CertificateTool.resolvePath(value);
        }

        /**
         * Returns the CA certificate and private key that will be used to sign certificates. These may be specified by the user or
         * automatically generated
         *
         * @return CA cert and private key
         */
        CAInfo getCAInfo(Terminal terminal, OptionSet options, Environment env) throws Exception {
            if (options.has(caPkcs12PathSpec)) {
                return loadPkcs12CA(terminal, options, env);
            } else if (options.has(caCertPathSpec)) {
                return loadPemCA(terminal, options, env);
            } else {
                terminal.println("Note: Generating certificates without providing a CA certificate is deprecated.");
                terminal.println("      A CA certificate will become mandatory in the next major release.");
                terminal.println("");
                return generateCA(terminal, options);
            }
        }

        private CAInfo loadPkcs12CA(Terminal terminal, OptionSet options, Environment env) throws Exception {
            Path path = resolvePath(options, caPkcs12PathSpec);
            char[] passwordOption = getChars(caPasswordSpec.value(options));
            Map<Certificate, Key> keys = withPassword(
                "CA (" + path + ")",
                passwordOption,
                terminal,
                false,
                password -> CertParsingUtils.readPkcs12KeyPairs(path, password, a -> password)
            );

            if (keys.size() != 1) {
                throw new IllegalArgumentException(
                    "expected a single key in file [" + path.toAbsolutePath() + "] but found [" + keys.size() + "]"
                );
            }
            final Map.Entry<Certificate, Key> pair = keys.entrySet().iterator().next();
            return new CAInfo((X509Certificate) pair.getKey(), (PrivateKey) pair.getValue());
        }

        private CAInfo loadPemCA(Terminal terminal, OptionSet options, Environment env) throws Exception {
            if (options.hasArgument(caKeyPathSpec) == false) {
                throw new UserException(ExitCodes.USAGE, "Option " + caCertPathSpec + " also requires " + caKeyPathSpec);
            }
            Path cert = resolvePath(options, caCertPathSpec);
            Path key = resolvePath(options, caKeyPathSpec);
            String password = caPasswordSpec.value(options);

            X509Certificate caCert = CertParsingUtils.readX509Certificate(cert);
            PrivateKey privateKey = readPrivateKey(key, getChars(password), terminal);
            return new CAInfo(caCert, privateKey);
        }

        CAInfo generateCA(Terminal terminal, OptionSet options) throws Exception {
            String dn = caDnSpec.value(options);
            if (Strings.isNullOrEmpty(dn)) {
                dn = AUTO_GEN_CA_DN;
            }
            X500Principal x500Principal = new X500Principal(dn);
            KeyPair keyPair = CertGenUtils.generateKeyPair(getKeySize(options));
            X509Certificate caCert = CertGenUtils.generateCACertificate(x500Principal, keyPair, getDays(options));

            if (options.hasArgument(caPasswordSpec)) {
                char[] password = getChars(caPasswordSpec.value(options));
                checkAndConfirmPasswordLengthForOpenSSLCompatibility(password, terminal, false);
                return new CAInfo(caCert, keyPair.getPrivate(), true, password);
            }
            if (options.has(caPasswordSpec)) {
                return withPassword("CA Private key", null, terminal, true, p -> new CAInfo(caCert, keyPair.getPrivate(), true, p.clone()));
            }
            return new CAInfo(caCert, keyPair.getPrivate(), true, null);
        }

        /**
         * This method handles the collection of information about each instance that is necessary to generate a certificate. The user may
         * be prompted or the information can be gathered from a file
         *
         * @return a {@link Collection} of {@link CertificateInformation} that represents each instance
         */
        Collection<CertificateInformation> getCertificateInformationList(Terminal terminal, OptionSet options) throws Exception {
            final Path input = resolvePath(options, inputFileSpec);
            if (input != null) {
                return parseAndValidateFile(terminal, input.toAbsolutePath());
            }
            if (options.has(multipleNodesSpec)) {
                return readMultipleCertificateInformation(terminal);
            } else {
                final Function<String, Stream<? extends String>> splitByComma = v -> Arrays.stream(Strings.splitStringByCommaToArray(v));
                final List<String> dns = dnsNamesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList());
                final List<String> ip = ipAddressesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList());
                final List<String> cn = null;
                final String name = getCertificateName(options);
                final String fileName;
                if (Name.isValidFilename(name)) {
                    fileName = name;
                } else {
                    fileName = requestFileName(terminal, name);
                }
                CertificateInformation information = new CertificateInformation(name, fileName, ip, dns, cn);
                List<String> validationErrors = information.validate();
                if (validationErrors.isEmpty()) {
                    return Collections.singleton(information);
                } else {
                    validationErrors.forEach(terminal::errorPrintln);
                    return Collections.emptyList();
                }
            }
        }

        protected String getCertificateName(OptionSet options) {
            return options.has(nameSpec) ? nameSpec.value(options) : DEFAULT_CERT_NAME;
        }

        static Collection<CertificateInformation> readMultipleCertificateInformation(Terminal terminal) {
            Map<String, CertificateInformation> map = new HashMap<>();
            boolean done = false;
            while (done == false) {
                String name = terminal.readText("Enter instance name: ");
                if (name.isEmpty() == false) {
                    String filename = requestFileName(terminal, name);
                    String ipAddresses = terminal.readText("Enter IP Addresses for instance (comma-separated if more than one) []: ");
                    String dnsNames = terminal.readText("Enter DNS names for instance (comma-separated if more than one) []: ");
                    List<String> ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses));
                    List<String> dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames));
                    List<String> commonNames = null;

                    CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames);
                    List<String> validationErrors = information.validate();
                    if (validationErrors.isEmpty()) {
                        if (map.containsKey(name)) {
                            terminal.println("Overwriting previously defined instance information [" + name + "]");
                        }
                        map.put(name, information);
                    } else {
                        for (String validationError : validationErrors) {
                            terminal.println(validationError);
                        }
                        terminal.println("Skipping entry as invalid values were found");
                    }
                } else {
                    terminal.println("A name must be provided");
                }

                String exit = terminal.readText(
                    "Would you like to specify another instance? Press 'y' to continue entering instance " + "information: "
                );
                if ("y".equals(exit) == false) {
                    done = true;
                }
            }
            return map.values();
        }

        private static String requestFileName(Terminal terminal, String certName) {
            final boolean isNameValidFilename = Name.isValidFilename(certName);
            while (true) {
                String filename = terminal.readText(
                    "Enter name for directories and files of " + certName + (isNameValidFilename ? " [" + certName + "]" : "") + ": "
                );
                if (filename.isEmpty() && isNameValidFilename) {
                    return certName;
                }
                if (Name.isValidFilename(filename)) {
                    return filename;
                } else {
                    terminal.errorPrintln(Terminal.Verbosity.SILENT, "'" + filename + "' is not a valid filename");
                    continue;
                }
            }
        }

        /**
         * This method handles writing out the certificate authority in PEM format to a zip file.
         *
         * @param outputStream the output stream to write to
         * @param pemWriter    the writer for PEM objects
         * @param info         the certificate authority information
         * @param includeKey   if true, write the CA key in PEM format
         */
        static void writeCAInfo(ZipOutputStream outputStream, JcaPEMWriter pemWriter, CAInfo info, boolean includeKey) throws Exception {
            final String caDirName = createCaDirectory(outputStream);
            outputStream.putNextEntry(new ZipEntry(caDirName + "ca.crt"));
            pemWriter.writeObject(info.certAndKey.cert);
            pemWriter.flush();
            outputStream.closeEntry();
            if (includeKey) {
                outputStream.putNextEntry(new ZipEntry(caDirName + "ca.key"));
                if (info.password != null && info.password.length > 0) {
                    try {
                        PEMEncryptor encryptor = getEncrypter(info.password);
                        pemWriter.writeObject(info.certAndKey.key, encryptor);
                    } finally {
                        // we can safely nuke the password chars now
                        Arrays.fill(info.password, (char) 0);
                    }
                } else {
                    pemWriter.writeObject(info.certAndKey.key);
                }
                pemWriter.flush();
                outputStream.closeEntry();
            }
        }

        private static String createCaDirectory(ZipOutputStream outputStream) throws IOException {
            final String caDirName = "ca/";
            ZipEntry zipEntry = new ZipEntry(caDirName);
            assert zipEntry.isDirectory();
            outputStream.putNextEntry(zipEntry);
            return caDirName;
        }

        static void writePkcs12(
            String fileName,
            OutputStream output,
            String alias,
            CertificateAndKey pair,
            X509Certificate caCert,
            char[] password,
            Terminal terminal
        ) throws Exception {
            final KeyStore pkcs12 = KeyStore.getInstance("PKCS12");
            pkcs12.load(null);
            withPassword(fileName, password, terminal, true, p12Password -> {
                if (isAscii(p12Password)) {
                    pkcs12.setKeyEntry(alias, pair.key, p12Password, new Certificate[] { pair.cert });
                    if (caCert != null) {
                        pkcs12.setCertificateEntry("ca", caCert);
                    }
                    pkcs12.store(output, p12Password);
                    return null;
                } else {
                    throw new UserException(ExitCodes.CONFIG, "PKCS#12 passwords must be plain ASCII");
                }
            });
        }

        /**
         * Verify that the provided certificate is validly signed by the provided CA
         */
        static void verifyIssuer(Certificate certificate, CAInfo caInfo, Terminal terminal) throws UserException {
            try {
                certificate.verify(caInfo.certAndKey.cert.getPublicKey());
            } catch (GeneralSecurityException e) {
                terminal.errorPrintln("");
                terminal.errorPrintln("* ERROR *");
                terminal.errorPrintln("Verification of generated certificate failed.");
                terminal.errorPrintln("This usually occurs if the provided CA certificate does not match with the CA key.");
                terminal.errorPrintln("Cause: " + e);
                for (var c = e.getCause(); c != null; c = c.getCause()) {
                    terminal.errorPrintln("     - " + c);
                }
                throw new UserException(ExitCodes.CONFIG, "Certificate verification failed");
            }
        }

        protected void writePemPrivateKey(
            Terminal terminal,
            OptionSet options,
            ZipOutputStream outputStream,
            JcaPEMWriter pemWriter,
            String keyFileName,
            PrivateKey privateKey
        ) throws IOException {
            final boolean usePassword = useOutputPassword(options);
            final char[] outputPassword = getOutputPassword(options);
            outputStream.putNextEntry(new ZipEntry(keyFileName));
            if (usePassword) {
                withPassword(keyFileName, outputPassword, terminal, true, password -> {
                    pemWriter.writeObject(privateKey, getEncrypter(password));
                    return null;
                });
            } else {
                pemWriter.writeObject(privateKey);
            }
            pemWriter.flush();
            outputStream.closeEntry();
        }
    }

    static class SigningRequestCommand extends CertificateCommand {

        SigningRequestCommand() {
            super("generate certificate signing requests");
            acceptInstanceDetails();
            acceptInputFile();
        }

        @Override
        public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'csr' mode generates certificate signing requests that can be sent to");
            terminal.println("a trusted certificate authority");
            terminal.println("    * By default, this generates a single CSR for a single instance.");
            terminal.println("    * You can use the '-multiple' option to generate CSRs for multiple");
            terminal.println("       instances, each with their own private key.");
            terminal.println("    * The '-in' option allows for the CSR generation to be automated");
            terminal.println("       by describing the details of each instance in a YAML file");
            terminal.println("");
            terminal.println(INSTANCE_EXPLANATION);
            terminal.println("");
            terminal.println("The 'csr' mode produces a single zip file which contains the certificate");
            terminal.println("signing requests and private keys for each instance.");
            terminal.println("    * Each certificate signing request is provided as a standard PEM encoding of a PKCS#10 CSR.");
            terminal.println("    * Each key is provided as a PEM encoding of an RSA private key");
            terminal.println("");

            final Path output = resolveOutputPath(terminal, options, DEFAULT_CSR_ZIP);
            generateAndWriteCsrs(terminal, options, output);

            terminal.println("");
            terminal.println("Certificate signing requests have been written to " + output);
            terminal.println("");
            terminal.println("This file should be properly secured as it contains the private keys for all");
            terminal.println("instances.");
            terminal.println("");
            terminal.println("After unzipping the file, there will be a directory for each instance containing");
            terminal.println("the certificate signing request and the private key. Provide the certificate");
            terminal.println("signing requests to your certificate authority. Once you have received the");
            terminal.println("signed certificate, copy the signed certificate, key, and CA certificate to the");
            terminal.println("configuration directory of the Elastic product that they will be used for and");
            terminal.println("follow the SSL configuration instructions in the product guide.");
        }

        // For testing
        void generateAndWriteCsrs(Terminal terminal, OptionSet options, Path output) throws Exception {
            final int keySize = getKeySize(options);
            Collection<CertificateInformation> certificateInformations = getCertificateInformationList(terminal, options);
            generateAndWriteCsrs(terminal, options, output, keySize, certificateInformations);
        }

        /**
         * Generates certificate signing requests and writes them out to the specified file in zip format
         *
         * @param certInfo the details to use in the certificate signing requests
         */
        void generateAndWriteCsrs(
            Terminal terminal,
            OptionSet options,
            Path output,
            int keySize,
            Collection<CertificateInformation> certInfo
        ) throws Exception {
            fullyWriteZipFile(output, (outputStream, pemWriter) -> {
                for (CertificateInformation certificateInformation : certInfo) {
                    KeyPair keyPair = CertGenUtils.generateKeyPair(keySize);
                    GeneralNames sanList = getSubjectAlternativeNamesValue(
                        certificateInformation.ipAddresses,
                        certificateInformation.dnsNames,
                        certificateInformation.commonNames
                    );
                    PKCS10CertificationRequest csr = CertGenUtils.generateCSR(keyPair, certificateInformation.name.x500Principal, sanList);

                    final String dirName = certificateInformation.name.filename + "/";
                    ZipEntry zipEntry = new ZipEntry(dirName);
                    assert zipEntry.isDirectory();
                    outputStream.putNextEntry(zipEntry);

                    // write csr
                    outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".csr"));
                    pemWriter.writeObject(csr);
                    pemWriter.flush();
                    outputStream.closeEntry();

                    // write private key
                    super.writePemPrivateKey(
                        terminal,
                        options,
                        outputStream,
                        pemWriter,
                        dirName + certificateInformation.name.filename + ".key",
                        keyPair.getPrivate()
                    );
                }
            });
        }
    }

    static class GenerateCertificateCommand extends CertificateCommand {

        OptionSpec<Void> selfSigned;

        GenerateCertificateCommand() {
            super("generate X.509 certificates and keys");
            acceptCertificateGenerationOptions();
            acceptInstanceDetails();
            acceptsCertificateAuthority();
            acceptInputFile();
            selfSigned = parser.accepts("self-signed", "generate self signed certificates")
                .availableUnless(caPkcs12PathSpec, caCertPathSpec);
        }

        @Override
        public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'cert' mode generates X.509 certificate and private keys.");
            terminal.println("    * By default, this generates a single certificate and key for use");
            terminal.println("       on a single instance.");
            terminal.println("    * The '-multiple' option will prompt you to enter details for multiple");
            terminal.println("       instances and will generate a certificate and key for each one");
            terminal.println("    * The '-in' option allows for the certificate generation to be automated by describing");
            terminal.println("       the details of each instance in a YAML file");
            terminal.println("");
            terminal.println(INSTANCE_EXPLANATION);
            terminal.println("");
            terminal.println(CA_EXPLANATION);
            terminal.println("");
            terminal.println("By default the 'cert' mode produces a single PKCS#12 output file which holds:");
            terminal.println("    * The instance certificate");
            terminal.println("    * The private key for the instance certificate");
            terminal.println("    * The CA certificate");
            terminal.println("");
            terminal.println("If you specify any of the following options:");
            terminal.println("    * -pem (PEM formatted output)");
            terminal.println("    * -multiple (generate multiple certificates)");
            terminal.println("    * -in (generate certificates from an input file)");
            terminal.println("then the output will be be a zip file containing individual certificate/key files");
            terminal.println("");

            CAInfo caInfo = getCAInfo(terminal, options, env);
            Collection<CertificateInformation> certInfo = getCertificateInformationList(terminal, options);
            final boolean usePemFormat = usePemFormat(options);
            final boolean writeZipFile = options.has(multipleNodesSpec) || options.has(inputFileSpec) || usePemFormat;

            final String outputName;
            if (writeZipFile) {
                outputName = DEFAULT_CERT_ZIP;
            } else if (options.has(nameSpec)) {
                outputName = nameSpec.value(options) + ".p12";
            } else {
                outputName = "elastic-certificates.p12";
            }
            final Path output = resolveOutputPath(terminal, options, outputName);

            generateAndWriteSignedCertificates(output, writeZipFile, options, certInfo, caInfo, terminal);

            terminal.println("");
            terminal.println("Certificates written to " + output);
            terminal.println("");
            if (certInfo.size() > 1) {
                terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private keys for ");
                terminal.print(Terminal.Verbosity.NORMAL, "all instances");
            } else {
                terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private key for ");
                terminal.print(Terminal.Verbosity.NORMAL, "your instance.");
            }
            terminal.println("");
            final String filesDescription;
            if (writeZipFile) {
                terminal.println("After unzipping the file, there will be a directory for each instance.");
                if (usePemFormat) {
                    terminal.println("Each instance has a certificate and private key.");
                    filesDescription = "the certificate, key, and CA certificate";
                } else {
                    terminal.println("Each instance has a single PKCS#12 (.p12) file containing the instance");
                    terminal.println("certificate, instance private key and the CA certificate");
                    filesDescription = "this '.p12' file";
                }
            } else {
                terminal.println("This file is a self contained file and can be copied and used 'as is'");
                filesDescription = "this '.p12' file";
            }
            terminal.println("For each Elastic product that you wish to configure, you should copy");
            terminal.println(filesDescription + " to the relevant configuration directory");
            terminal.println("and then follow the SSL configuration instructions in the product guide.");
            terminal.println("");
            if (usePemFormat || (caInfo != null && caInfo.generated == false)) {
                terminal.println("For client applications, you may only need to copy the CA certificate and");
                terminal.println("configure the client to trust this certificate.");
            }
        }

        @Override
        CAInfo getCAInfo(Terminal terminal, OptionSet options, Environment env) throws Exception {
            if (false == options.has(selfSigned) && false == options.has(caPkcs12PathSpec) && false == options.has(caCertPathSpec)) {
                throw new UserException(ExitCodes.USAGE, "Must specify either --ca or --ca-cert/--ca-key or --self-signed");
            }
            return options.has(selfSigned) ? null : super.getCAInfo(terminal, options, env);
        }

        /**
         * Generates signed certificates in either PKCS#12 format or PEM format, wrapped in a zip file if necessary.
         *
         * @param output       the output file (either zip, or PKCS#12)
         * @param writeZipFile if true, output a zip file, otherwise output a single PKCS#12 file
         * @param options      the current command line options
         * @param certs        the certificates to write to the file
         * @param caInfo       the CA information to sign the certificates with
         * @param terminal     the terminal to use if prompting for passwords
         */
        void generateAndWriteSignedCertificates(
            Path output,
            boolean writeZipFile,
            OptionSet options,
            Collection<CertificateInformation> certs,
            CAInfo caInfo,
            Terminal terminal
        ) throws Exception {

            checkDirectory(output, terminal);

            final int keySize = getKeySize(options);
            final int days = getDays(options);
            if (writeZipFile) {
                final boolean usePem = usePemFormat(options);
                fullyWriteZipFile(output, (outputStream, pemWriter) -> {
                    for (CertificateInformation certificateInformation : certs) {
                        CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days, terminal);

                        final String dirName = certificateInformation.name.filename + "/";
                        ZipEntry zipEntry = new ZipEntry(dirName);
                        assert zipEntry.isDirectory();
                        outputStream.putNextEntry(zipEntry);

                        final String entryBase = dirName + certificateInformation.name.filename;

                        if (usePem) {
                            // write cert
                            outputStream.putNextEntry(new ZipEntry(entryBase + ".crt"));
                            pemWriter.writeObject(pair.cert);
                            pemWriter.flush();
                            outputStream.closeEntry();

                            // write private key
                            writePemPrivateKey(terminal, options, outputStream, pemWriter, entryBase + ".key", pair.key);
                        } else {
                            final String fileName = entryBase + ".p12";
                            final char[] outputPassword = super.getOutputPassword(options);
                            outputStream.putNextEntry(new ZipEntry(fileName));
                            writePkcs12(
                                fileName,
                                outputStream,
                                certificateInformation.name.originalName,
                                pair,
                                caInfo == null ? null : caInfo.certAndKey.cert,
                                outputPassword,
                                terminal
                            );
                            outputStream.closeEntry();
                        }
                    }
                });
            } else {
                assert certs.size() == 1;
                final char[] outputPassword = super.getOutputPassword(options);
                CertificateInformation certificateInformation = certs.iterator().next();
                CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days, terminal);
                fullyWriteFile(
                    output,
                    stream -> writePkcs12(
                        output.getFileName().toString(),
                        stream,
                        certificateInformation.name.originalName,
                        pair,
                        caInfo == null ? null : caInfo.certAndKey.cert,
                        outputPassword,
                        terminal
                    )
                );
            }
        }

        private static CertificateAndKey generateCertificateAndKey(
            CertificateInformation certificateInformation,
            CAInfo caInfo,
            int keySize,
            int days,
            Terminal terminal
        ) throws Exception {
            KeyPair keyPair = CertGenUtils.generateKeyPair(keySize);
            Certificate certificate;
            if (caInfo != null) {
                certificate = CertGenUtils.generateSignedCertificate(
                    certificateInformation.name.x500Principal,
                    getSubjectAlternativeNamesValue(
                        certificateInformation.ipAddresses,
                        certificateInformation.dnsNames,
                        certificateInformation.commonNames
                    ),
                    keyPair,
                    caInfo.certAndKey.cert,
                    caInfo.certAndKey.key,
                    days
                );
                verifyIssuer(certificate, caInfo, terminal);
            } else {
                certificate = CertGenUtils.generateSignedCertificate(
                    certificateInformation.name.x500Principal,
                    getSubjectAlternativeNamesValue(
                        certificateInformation.ipAddresses,
                        certificateInformation.dnsNames,
                        certificateInformation.commonNames
                    ),
                    keyPair,
                    null,
                    null,
                    false,
                    days,
                    null
                );
            }
            return new CertificateAndKey((X509Certificate) certificate, keyPair.getPrivate());
        }

    }

    static class CertificateAuthorityCommand extends CertificateCommand {

        CertificateAuthorityCommand() {
            super("generate a new local certificate authority");
            acceptCertificateGenerationOptions();
            acceptsCertificateAuthorityName();
            super.caPasswordSpec = super.outputPasswordSpec;
        }

        @Override
        public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'ca' mode generates a new 'certificate authority'");
            terminal.println("This will create a new X.509 certificate and private key that can be used");
            terminal.println("to sign certificate when running in 'cert' mode.");
            terminal.println("");
            terminal.println("Use the 'ca-dn' option if you wish to configure the 'distinguished name'");
            terminal.println("of the certificate authority");
            terminal.println("");
            terminal.println("By default the 'ca' mode produces a single PKCS#12 output file which holds:");
            terminal.println("    * The CA certificate");
            terminal.println("    * The CA's private key");
            terminal.println("");
            terminal.println("If you elect to generate PEM format certificates (the -pem option), then the output will");
            terminal.println("be a zip file containing individual files for the CA certificate and private key");
            terminal.println("");

            CAInfo caInfo = generateCA(terminal, options);
            final boolean writeZipFile = usePemFormat(options);
            final Path output = resolveOutputPath(terminal, options, writeZipFile ? DEFAULT_CA_ZIP : DEFAULT_CA_P12);
            writeCertificateAuthority(output, caInfo, writeZipFile, terminal);
        }

        private void writeCertificateAuthority(Path output, CAInfo caInfo, boolean writePemZip, Terminal terminal) throws Exception {
            checkDirectory(output, terminal);
            if (writePemZip) {
                fullyWriteZipFile(output, (outputStream, pemWriter) -> writeCAInfo(outputStream, pemWriter, caInfo, true));
            } else {
                final String fileName = output.getFileName().toString();
                fullyWriteFile(
                    output,
                    outputStream -> writePkcs12(fileName, outputStream, "ca", caInfo.certAndKey, null, caInfo.password, terminal)
                );
            }
        }

    }

    @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool")
    static Path resolvePath(String pathStr) {
        return PathUtils.get(pathStr).normalize();
    }

    static Collection<CertificateInformation> parseAndValidateFile(Terminal terminal, Path file) throws Exception {
        final Collection<CertificateInformation> config = parseFile(file);
        boolean hasError = false;
        for (CertificateInformation certInfo : config) {
            final List<String> errors = certInfo.validate();
            if (errors.size() > 0) {
                hasError = true;
                terminal.errorPrintln(
                    Verbosity.SILENT,
                    "Configuration for instance " + certInfo.name.originalName + " has invalid details"
                );
                for (String message : errors) {
                    terminal.errorPrintln(Verbosity.SILENT, " * " + message);
                }
                terminal.errorPrintln("");
            }
        }
        if (hasError) {
            throw new UserException(ExitCodes.CONFIG, "File " + file + " contains invalid configuration details (see messages above)");
        }
        return config;
    }

    /**
     * Parses the input file to retrieve the certificate information
     *
     * @param file the file to parse
     * @return a collection of certificate information
     */
    static Collection<CertificateInformation> parseFile(Path file) throws Exception {
        try (Reader reader = Files.newBufferedReader(file)) {
            XContentParser xContentParser = XContentType.YAML.xContent().createParser(XContentParserConfiguration.EMPTY, reader);
            return CertificateToolParser.PARSER.parse(xContentParser, new ArrayList<>(), null);
        }
    }

    static PEMEncryptor getEncrypter(char[] password) {
        return new JcePEMEncryptorBuilder("AES-128-CBC").setProvider(BC_PROV).build(password);
    }

    /**
     * Checks whether the supplied password exceeds the maximum length supported by older OpenSSL versions.
     * A warning message is printed to the terminal if the password is too long. If {@code confirm} is true, then the user
     * (via the terminal) is asked to confirm whether to continue with the potentially problematic password.
     * @return {@code false} if the password is too long <em>and</em> the user elects to reject it, otherwise {@code true}.
     */
    static boolean checkAndConfirmPasswordLengthForOpenSSLCompatibility(char[] password, Terminal terminal, boolean confirm) {
        if (password.length > MAX_PASSWORD_OLD_OPENSSL) {
            terminal.println(
                Verbosity.SILENT,
                "Warning: Your password exceeds "
                    + MAX_PASSWORD_OLD_OPENSSL
                    + " characters. Versions of OpenSSL older than "
                    + OLD_OPENSSL_VERSION
                    + " may not be able to read this file."
            );
            if (confirm) {
                return terminal.promptYesNo("Do you want to continue?", true);
            }
        }
        return true;
    }

    private static <T, E extends Exception> T withPassword(
        String description,
        char[] password,
        Terminal terminal,
        boolean checkLength,
        CheckedFunction<char[], T, E> body
    ) throws E {
        if (password == null) {
            while (true) {
                char[] promptedValue = terminal.readSecret("Enter password for " + description + " : ");
                if (checkLength && checkAndConfirmPasswordLengthForOpenSSLCompatibility(promptedValue, terminal, true) == false) {
                    continue;
                }
                try {
                    return body.apply(promptedValue);
                } finally {
                    Arrays.fill(promptedValue, (char) 0);
                }
            }
        } else {
            if (checkLength) {
                checkAndConfirmPasswordLengthForOpenSSLCompatibility(password, terminal, false);
            }
            return body.apply(password);
        }
    }

    /**
     * This method handles the deletion of a file in the case of a partial write
     *
     * @param file   the file that is being written to
     * @param writer writes the contents of the file
     */
    private static void fullyWriteZipFile(Path file, Writer writer) throws Exception {
        fullyWriteFile(file, outputStream -> {
            try (
                ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8);
                JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(zipOutputStream, StandardCharsets.UTF_8))
            ) {
                writer.write(zipOutputStream, pemWriter);
            }
        });
    }

    /**
     * Checks whether the parent directories of {@code path} exist, and offers to create them if needed.
     */
    private static void checkDirectory(Path path, Terminal terminal) throws UserException {
        final Path parent = path.getParent();
        if (Files.isDirectory(parent)) {
            return;
        }
        if (Files.exists(parent)) {
            terminal.errorPrintln(Terminal.Verbosity.SILENT, "Path " + parent + " exists, but is not a directory. Cannot write to " + path);
            throw new UserException(ExitCodes.CANT_CREATE, "Cannot write to " + path);
        }
        if (terminal.promptYesNo("Directory " + parent + " does not exist. Do you want to create it?", true)) {
            try {
                Files.createDirectories(parent);
            } catch (IOException e) {
                throw new UserException(ExitCodes.CANT_CREATE, "Cannot create directory " + parent, e);
            }
        } else {
            throw new UserException(ExitCodes.CANT_CREATE, "Directory " + parent + " does not exist");
        }

    }

    /**
     * This method handles the deletion of a file in the case of a partial write
     *
     * @param file   the file that is being written to
     * @param writer writes the contents of the file
     */
    private static void fullyWriteFile(Path file, CheckedConsumer<OutputStream, Exception> writer) throws Exception {
        assert file != null;
        assert writer != null;

        boolean success = false;
        if (Files.exists(file)) {
            throw new UserException(ExitCodes.IO_ERROR, "Output file '" + file + "' already exists");
        }
        try (OutputStream outputStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW)) {
            writer.accept(outputStream);

            // set permissions to 600
            PosixFileAttributeView view = Files.getFileAttributeView(file, PosixFileAttributeView.class);
            if (view != null) {
                view.setPermissions(Sets.newHashSet(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
            }

            success = true;
        } finally {
            if (success == false) {
                Files.deleteIfExists(file);
            }
        }
    }

    /**
     * Helper method to read a private key and support prompting of user for a key. To avoid passwords being placed as an argument we
     * can prompt the user for their password if we encounter an encrypted key.
     *
     * @param path     the path to the private key
     * @param password the password provided by the user or {@code null}
     * @param terminal the terminal to use for user interaction
     * @return the {@link PrivateKey} that was read from the file
     */
    private static PrivateKey readPrivateKey(Path path, char[] password, Terminal terminal) throws Exception {
        AtomicReference<char[]> passwordReference = new AtomicReference<>(password);
        try {
            return PemUtils.readPrivateKey(path, () -> {
                if (password != null) {
                    return password;
                }
                char[] promptedValue = terminal.readSecret("Enter password for CA private key (" + path.getFileName() + ") : ");
                passwordReference.set(promptedValue);
                return promptedValue;
            });
        } finally {
            if (passwordReference.get() != null) {
                Arrays.fill(passwordReference.get(), (char) 0);
            }
        }
    }

    static GeneralNames getSubjectAlternativeNamesValue(List<String> ipAddresses, List<String> dnsNames, List<String> commonNames) {
        Set<GeneralName> generalNameList = new HashSet<>();
        for (String ip : ipAddresses) {
            generalNameList.add(new GeneralName(GeneralName.iPAddress, ip));
        }

        for (String dns : dnsNames) {
            generalNameList.add(new GeneralName(GeneralName.dNSName, dns));
        }

        for (String cn : commonNames) {
            generalNameList.add(CertGenUtils.createCommonName(cn));
        }

        if (generalNameList.isEmpty()) {
            return null;
        }
        return new GeneralNames(generalNameList.toArray(new GeneralName[0]));
    }

    static boolean isAscii(char[] str) {
        return ASCII_ENCODER.canEncode(CharBuffer.wrap(str));
    }

    private static char[] getChars(String password) {
        return password == null ? null : password.toCharArray();
    }

    static class CertificateInformation {
        final Name name;
        final List<String> ipAddresses;
        final List<String> dnsNames;
        final List<String> commonNames;

        CertificateInformation(String name, String filename, List<String> ipAddresses, List<String> dnsNames, List<String> commonNames) {
            this.name = Name.fromUserProvidedName(name, filename);
            this.ipAddresses = ipAddresses == null ? Collections.emptyList() : ipAddresses;
            this.dnsNames = dnsNames == null ? Collections.emptyList() : dnsNames;
            this.commonNames = commonNames == null ? Collections.emptyList() : commonNames;
        }

        List<String> validate() {
            List<String> errors = new ArrayList<>();
            if (name.error != null) {
                errors.add(name.error);
            }
            for (String ip : ipAddresses) {
                if (InetAddresses.isInetAddress(ip) == false) {
                    errors.add("[" + ip + "] is not a valid IP address");
                }
            }
            for (String dnsName : dnsNames) {
                if (DERIA5String.isIA5String(dnsName) == false) {
                    errors.add("[" + dnsName + "] is not a valid DNS name");
                }
            }
            return errors;
        }
    }

    static class Name {

        final String originalName;
        final X500Principal x500Principal;
        final String filename;
        final String error;

        private Name(String name, X500Principal x500Principal, String filename, String error) {
            this.originalName = name;
            this.x500Principal = x500Principal;
            this.filename = filename;
            this.error = error;
        }

        static Name fromUserProvidedName(String name, String filename) {
            if ("ca".equals(name)) {
                return new Name(name, null, null, "[ca] may not be used as an instance name");
            }
            if (name == null) {
                return new Name("", null, null, "instance name may not be null");
            }

            final X500Principal principal;
            try {
                if (name.contains("=")) {
                    principal = new X500Principal(name);
                } else {
                    principal = new X500Principal("CN=" + name);
                }
            } catch (IllegalArgumentException e) {
                String error = "["
                    + name
                    + "] could not be converted to a valid DN\n"
                    + e.getMessage()
                    + "\n"
                    + ExceptionsHelper.stackTrace(e);
                return new Name(name, null, null, error);
            }

            boolean validFilename = isValidFilename(filename);
            if (validFilename == false) {
                return new Name(name, principal, null, "[" + filename + "] is not a valid filename");
            }
            return new Name(name, principal, resolvePath(filename).toString(), null);
        }

        static boolean isValidFilename(String name) {
            return ALLOWED_FILENAME_CHAR_PATTERN.matcher(name).matches()
                && ALLOWED_FILENAME_CHAR_PATTERN.matcher(resolvePath(name).toString()).matches()
                && name.startsWith(".") == false;
        }

        @Override
        public String toString() {
            return getClass().getSimpleName()
                + "{original=["
                + originalName
                + "] principal=["
                + x500Principal
                + "] file=["
                + filename
                + "] err=["
                + error
                + "]}";
        }
    }

    static class CertificateAndKey {
        final X509Certificate cert;
        final PrivateKey key;

        CertificateAndKey(X509Certificate cert, PrivateKey key) {
            this.cert = cert;
            this.key = key;
        }
    }

    static class CAInfo {
        final CertificateAndKey certAndKey;
        final boolean generated;
        final char[] password;

        CAInfo(X509Certificate caCert, PrivateKey privateKey) {
            this(caCert, privateKey, false, null);
        }

        CAInfo(X509Certificate caCert, PrivateKey privateKey, boolean generated, char[] password) {
            this.certAndKey = new CertificateAndKey(caCert, privateKey);
            this.generated = generated;
            this.password = password;
        }
    }

    private interface Writer {
        void write(ZipOutputStream zipOutputStream, JcaPEMWriter pemWriter) throws Exception;
    }

}
